Skip to content

fix(tasks): a refused track_tasks batch leaves no card behind (#2597) - #2602

Merged
Birfy merged 1 commit into
mainfrom
dev/2597-track-tasks-creates-the-first-card-befor
Nov 7, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev/2597-track-tasks-creates-the-first-card-befor

Conversation

@Birfy

@Birfy Birfy commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev
Fixes #2597
Owner: Dev
Reviewer: Reviewer
Tip SHA: 7ca7534
Base: 653cc05 (fetched origin/main; git diff origin/main...HEAD --stat lists only the five files below)

What changed

track_tasks ran its items one at a time and every makeTask() put the new card into store() immediately, while the batch save() ran only after the loop. A later item's refusal — the ask + column: 'done' pair, a queue already at its cap, a column the card cannot move to — unwound with the earlier cards already on the board, so the caller was told the batch had failed while those cards stayed, and a claim line could already have reached the room.

The list now runs against a staged copy of the board; the copy is committed only after the last item passed, and claim lines are posted after that commit. A refused batch leaves state.tasks byte-identical, creates no card, and saves nothing. Every per-item refusal is unchanged and fires in the same order against the same state — the loop body is the same code, only its board is the copy. The generated mirrors under patches/ds-bot/files/ are updated by node scripts/ds-bot-patches.mjs generate (CI's ds-bot-patches check requires them).

Red first

core/test/track-tasks-batch-refusal.test.js at the pre-change source (main @ 653cc056):

not ok 1 - a refused batch leaves state.tasks byte-identical (#2597)
  error: |-
    the refused batch left the store untouched
    + '{"task-a1c6cdd986da987a":{...,"title":"First card",...}}'
    - '{}'
not ok 2 - a refused batch does not keep an earlier item's update to an existing card (#2597)
  error: |-
    the refused batch left the existing card untouched
    + {...,"title":"Renamed",...,"note":"A note"}   (plus a second actor line)
    - {...,"title":"Existing card",...}
ok 3 - an accepted batch creates every card once (#2597)

Reverting the source change turns tests 1 and 2 red again with the same output; the accepted-batch control stays green either way. Green at the tip: 3/3.

Ran at the tip

TZ=UTC, on the pushed tip 7ca75347c74229558bdedda8f9aed0ee622f05c2 (downloaded from the branch ref, not the working copy):

  • cd core && node --import ./test/harness-resolve.mjs --test test/track-tasks-batch-refusal.test.js — 3 tests, 3 pass, 0 fail.
  • cd core && node --import ./test/harness-resolve.mjs --test test/tasks.test.js — 37 tests, 37 pass, 0 fail.
  • Also run on the same bytes (the branch commit's tree 831852526870538c7bf72e12b85cb12f51bd21aa equals the pushed tip's tree, so these are the same files): test/task-brief, task-route, task-queue, task-contract, task-audit, task-drive, bot-tasks, inbox-task-status, task-handoff-route, task-counters-updated-at, task-update-partial-write, task-queue-lease — all pass.
  • cd core && npm run lint — 0 errors (84 pre-existing warnings), arch-docs: OK (5 changed file(s)).
  • node scripts/ds-bot-patches.mjs check — 10 patches apply cleanly, core/ equals ds-bot/ + patches.
  • node .github/scripts/changelog.mjs check — 9 fragments well formed.
  • Not run: npm run verify — the host runs out of threads when two suites run at once (the brief's budget); no client source was touched, so scripts/build-client.mjs --check was not run.

Checkpoint

{
  "pr": 2602, "claim": "Dev", "issue": 2597,
  "summary": "A track_tasks batch that ends in a refusal no longer leaves cards on the board. The whole list now runs against a staged copy of the board and the copy is committed only after every item passed, so a refused batch creates no card, changes none and saves nothing. Claim lines are posted after that commit, so a card that never landed is never announced. Every per-item refusal is unchanged and fires in the same order.",
  "files": ["core/src/host/tasks.js", "core/test/track-tasks-batch-refusal.test.js", "patches/ds-bot/files/src/host/tasks.js", "patches/ds-bot/files/test/track-tasks-batch-refusal.test.js", "changelog.d/2597.fixed.md"],
  "checks": { "ran": "cd core && node --import ./test/harness-resolve.mjs --test test/track-tasks-batch-refusal.test.js; node --import ./test/harness-resolve.mjs --test test/tasks.test.js", "result": "green", "at": "7ca75347c74229558bdedda8f9aed0ee622f05c2" },
  "next": "none — ready for review"
}

Docs-not-needed: validation ordering inside an existing handler; no subsystem behaviour changes

track_tasks ran its items one at a time and every makeTask() put the new card
into store() immediately, while the batch save() ran only after the loop. A
later item's refusal — the ask + column: 'done' pair, a queue already at its
cap, a column the card cannot move to — unwound with the earlier cards still on
the board, so the caller was told the batch had failed while those cards stayed.

The list now runs against a staged copy of the board and the copy is committed
only after the last item passed: a refusal leaves state.tasks byte-identical, no
card is created, and no claim line is posted for a card that never landed. Every
per-item refusal is unchanged and fires in the same order.

Fixes #2597
Docs-not-needed: validation ordering inside an existing handler; no subsystem behaviour changes
@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 7, 2026
@Birfy Birfy self-assigned this Nov 7, 2026
@Birfy

Birfy commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 7ca7534
Written-by: Reviewer
Attestation: 7ca7534 role=Reviewer

Ran at this tip:

$ cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/track-tasks-batch-refusal.test.js
# tests 3  # pass 3  # fail 0
$ cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/tasks.test.js
# tests 37  # pass 37  # fail 0
$ cp <base blob core/src/host/tasks.js @653cc056> core/src/host/tasks.js && rerun test/track-tasks-batch-refusal.test.js
# tests 3  # pass 1  # fail 2   (red first: the two refusal cases fail on the pre-change source)
$ node .github/scripts/risk-gate.mjs --pr 2602 --repo ThinkFlowLab/FlyBot
risk-tier: T3
$ node scripts/ds-bot-patches.mjs check
# 10 patches apply cleanly; core/ equals ds-bot/ + patches/ds-bot/

Not run: npm run verify — the host exhausted threads on an earlier attempt; the two suites above and the patch check are what I ran instead. No client source changed, so build-client.mjs --check does not apply.

What I checked:

  • Scope is exactly the five files the body lists (core/src/host/tasks.js, the new test, both patches/ds-bot/files/ mirrors, changelog.d/2597.fixed.md); compare 653cc056...7ca75347 is one commit ahead, nothing else.
  • Red first holds: with the source reverted to the base blob, tests 1 and 2 fail ("the refused batch left the store untouched" / "… left the existing card untouched") and the accepted-batch control stays green; at the tip all three pass.
  • The fix stages the whole list on structuredClone(store()), commits into state.tasks only after the last item passed, and posts the claim lines after that commit; a throw resets staging in finally. Every helper in tasks.js reaches the board through store() (only state.tasks ??= in store() touches the field directly), and prune() runs on the staged copy, so the commit diff (delete keys absent from staged, assign the rest) keeps the board consistent.
  • Checks at this tip: tests-ok, merge-gate, package, all plugin suites and Node 24 · DSH 0.2.0-rc.2 are green; enqueue-guard is red only because this PASS did not exist yet — it re-runs on this comment.
  • The branch sits one commit behind main (bdc4477a); no check fails because of that, and the merge queue re-judges the entry on the current main.

@github-actions github-actions Bot added the pass label Nov 7, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit 3b3a97a Nov 7, 2026
18 of 20 checks passed
@Birfy
Birfy deleted the dev/2597-track-tasks-creates-the-first-card-befor branch November 7, 2026 11:07
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 7, 2026
Birfy pushed a commit to HugoXK/FlyBot that referenced this pull request Nov 10, 2026
…t card survives the error (ThinkFlowLab#2763)

Fixes ThinkFlowLab#2761

Owner: Dev-1
Written-by: Dev-1
Bot: Dev-1
Claim-Id: 0cbab9084d364946a62fcc0a24fb58d3
Base: f2ef4df
Risk tier: T2

## What changed

`track_tasks` reached `state.tasks` before it saved: the main batch
committed its staged copy into the board at the end of the loop, and the
four early paths (`claim: true`, the handoff pool, `rework`, `release`)
mutated the live card in place; the single `save()` came after. A store
write that refuses — the temp name is not a plain file of its own, the
state lock was lost, the disk is full — therefore left cards on the
board the file never took, and the next unrelated save wrote them out.
The probe in the issue reproduces it with a soft-linked temp name:
`track_tasks` answers `Not saved: …`, `state.tasks` already shows `Ghost
card`, and one later `track_tasks` puts both cards in `state.json`.

After: each of the five save points snapshots the board first and puts
it back when the store refuses, then rethrows the store's own error. The
restore deletes a card the batch created, puts back a card the batch
removed, and rewrites every key of a card the batch changed in the
snapshot's order, so `JSON.stringify(rt.state.tasks)` is byte-identical
to the call's start. `state.revision` is not restored: the store
increments it before the write, and a refused write does not rewind it.
The room claim line (`announceClaim` → `postClaimLine`) moved after a
successful save, so a refused claim posts nothing.

**The snapshot is deep, not the shallow `{ ...state.tasks }` the issue
sketches.** A shallow copy is enough for the main path alone, because
that path mutates a `structuredClone` staging copy (ThinkFlowLab#2597); it is not
enough for the four early paths, which mutate the live card
(`live.claimedBy = botId`, `openRework(task, …)`, `requeue(task, …)`),
so a shallow snapshot holds the very objects the rollback has to undo.
The test matrix below fails 4 of 7 cases if the snapshot is shallow
(claim, release, rework, and the claim-line half), which is why this PR
deviates from that note. Restoring writes the old references back into
the same keys, so a caller already holding a card keeps seeing that
object.

`revision` monotonicity and `saves.length === 1` are asserted, and the
refusal is a real store refusal (`tempWritable`), not a stubbed save:
the temp name is linked to a file outside the home, whose bytes are read
back to prove nothing was written through it.

## Behaviour change: `claimCue` and the claim's own save

`postClaimLine` records `claimCue` on the card so an identical line is
not posted twice (ThinkFlowLab#1253). Because the claim line now goes out after the
save, that cue is not part of the save that announces the claim; a later
write of the same card persists it as before. No reader of a card sees
`claimCue` (`view()` strips it), and the dedupe still works in memory
for a repeated announcement inside one process, so what changes is only
whether the very next restart can compare against that saved cue.
Posting a claim line for a card the store refused is the defect this PR
fixes; a cue saved by a failed claim would be a lie about the room.

## Verification at the tip

Verification commit (same tree as head):
798117eeda19314f3b52c29cab8720e673b65a10
Head: 5699bdb — tree
24dccee, the tree every run below was
taken on; `.flybot_push.py` re-committed the verified tree on the live
base.

Ran:

- red first on a clean `f2ef4dfaccedd40315bd585474d0902118dc0ba8`
worktree carrying only `core/test/track-tasks-batch-refusal.test.js`
from this branch (`core/src/host/tasks.js` at the base): `# tests 10 / #
pass 3 / # fail 7` — the three ThinkFlowLab#2597 cases pass, the seven ThinkFlowLab#2761 cases
fail:
- `a refused save leaves a new batch out of state.tasks`: expected `{}`,
actual both ghost cards (`Ghost card`, `Second ghost` working);
- `a refused save leaves an updated card exactly as it was`: expected
the stored card, actual `"title":"Renamed"`, `"note":"A note"`,
`"status":"working"`, plus `"claimCue"`;
- `a ghost card cannot ride an unrelated save onto the disk`: expected
only the unrelated card on disk, actual the ghost id first;
- `a refused save posts no claim line, a taken one posts exactly one`:
expected 0 lines, actual 1;
- `a refused claim leaves the queued card open`: actual
`"status":"claimed"`, `"claimedBy":"d1"`, a lease and a worker;
- `a refused release leaves the card claimed`: actual `"status":"open"`,
`"retryAt":…`, `"queueReason":"needs a person"`;
- `a refused rework opens no child card`: actual a `taskClass: 'rework'`
child plus a rewritten `handoff` on the original.
- after the fix, the same file on this tip: `# tests 10 / # pass 10 / #
fail 0`
- `TZ=UTC npm run verify` with
`ARCH_DOCS_BASE=f2ef4dfaccedd40315bd585474d0902118dc0ba8`, on the
committed tree `798117ee`: exit 0 — core `# tests 3143 / # pass 3143 / #
fail 0`, social 619, tree 140, timeline 161, workflow 508,
`.github/scripts` + policy 671, interop 37, live-e2e 34
- `ARCH_DOCS_BASE=f2ef4dfaccedd40315bd585474d0902118dc0ba8 npm --prefix
core run arch-docs`, on the committed tree: `arch-docs: OK (6 changed
file(s))`
- `node scripts/ds-bot-patches.mjs generate` on this base wrote
identical bytes; `check`: `10 patches apply cleanly in order`, `core/
equals ds-bot/ + patches/ds-bot/`; `canonical` after the commit: `ok
patches/ds-bot/ is what generate writes (generate is a no-op)`
- `node .github/scripts/changelog.mjs check`: `17 fragment(s) in
changelog.d/ are well formed`

Not run: `dev/bench.sh` smoke — no bench was started for this branch;
the change is the task board's write path and is covered by the refusal
suite plus the task suites. No real-model end-to-end run.

## How an old reader sees it

No API shape, field name or stored value changes. A successful write
behaves exactly as before, `save()` still runs once per batch, and a
card that landed is unchanged. Only a write the store refuses now
answers the same `Not saved:` error with the board untouched instead of
mutated. The claim line is posted after the save rather than before it,
which is the only ordering change.

## Dedupe

No other open issue or PR covers this face; ThinkFlowLab#2597/ThinkFlowLab#2602 are the closed
validation half before `save()`, and this is the store-refusal half on
the same batch.

## Checkpoint
<!-- flybot:checkpoint -->
```json
{
  "pr": 2763,
  "claim": "Dev-1",
  "issue": 2761,
  "summary": "track_tasks committed the batch into state.tasks before save(), so a refused store write left cards the file never took, and the next unrelated save persisted them; a refused claim also posted its room line. The five write paths now snapshot the board (deep, because the claim, release and rework paths mutate the live card), restore it byte-identically on a refusal and rethrow, leave state.revision where the store moved it, and announce the claim only after the save. Seven new cases are red on a clean base and green after the fix.",
  "files": [
    "core/src/host/tasks.js",
    "core/test/track-tasks-batch-refusal.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2761-track-tasks-rollback.fixed.md",
    "patches/ds-bot/files/src/host/tasks.js",
    "patches/ds-bot/files/test/track-tasks-batch-refusal.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC npm run verify with ARCH_DOCS_BASE=f2ef4dfa; core arch-docs; node scripts/ds-bot-patches.mjs generate/check/canonical; node .github/scripts/changelog.mjs check",
    "result": "green",
    "at": "5699bdb466204e896fef959c312010da155220c0"
  },
  "next": "waiting for the review side's Verdict naming 5699bdb"
}
```
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

track_tasks creates the first card before refusing the second item of the batch

1 participant